# Python imports
import os
import logging
import hmac
import re
import base64
import hashlib
import urllib

# Crypto imports 
from Crypto.PublicKey import RSA
from Crypto.Util import number

# AppEngine imports
from google.appengine.api import users
from google.appengine.runtime import DeadlineExceededError
from google.appengine.ext import db
from google.appengine.api import urlfetch

# Django imports
from django.http import HttpResponse
from django.http import Http404
from django.http import HttpResponseRedirect
from django.http import HttpResponseForbidden
from django.http import HttpResponseNotFound
from django.http import HttpResponseBadRequest
from django.http import HttpResponseServerError
from django.shortcuts import render_to_response
from django import forms
from django.conf import settings
from django.utils import simplejson

# Local imports 
import models
import oauth
import datetime

### Constants ###

IS_DEV = os.environ['SERVER_SOFTWARE'].startswith('Dev')  # Development server

### Forms ###

class AdminKeysForm(forms.Form):
  """Form for setting the signing keys"""
  ok = forms.BooleanField(required=True, initial=False, 
           label="Install new keys?",
           error_messages={"required": "You must agree to add new keys"},
           help_text="Check this box to update this app's OAuth keys")

### Decorators ###

def post_required(func):
  """Decorator that returns an error unless request.method == 'POST'."""
  def post_wrapper(request, *args, **kwds):
    if request.method != 'POST':
      return HttpResponse('This requires a POST request.', status=405)
    return func(request, *args, **kwds)

  return post_wrapper

def login_required(func):
  """Decorator that redirects to the login page if you're not logged in."""
  def login_wrapper(request, *args, **kwds):
    if request.user is None:
      return HttpResponseRedirect(
          users.create_login_url(request.get_full_path().encode('utf-8')))
    return func(request, *args, **kwds)
  return login_wrapper

def admin_required(func):
  """Decorator that insists that you're logged in as administratior."""
  def admin_wrapper(request, *args, **kwds):
    if request.user is None:
      return HttpResponseRedirect(
          users.create_login_url(request.get_full_path().encode('utf-8')))
    if not request.user_is_admin:
      return HttpResponseForbidden('You must be admin in for this function')
    return func(request, *args, **kwds)
  return admin_wrapper

def code_required(func):
  """Decorator that processes the code_id handler argument."""
  def code_wrapper(request, code_id=None, *args, **kwds):
    if not hasattr(request, "code_sample") or request.code_sample is None:
      try:
        code_sample = models.ddJavaScriptCodeSample.get(db.Key(code_id))
      except db.BadKeyError:
        return HttpResponseNotFound('No sample exists with that id (%s)' %
                                    code_id)
      request.code_sample = code_sample
    return func(request, *args, **kwds)
  return code_wrapper

def validate_signature(func):
  """Decorator that makes sure that the request signature is valid"""
  def validate_wrapper(request, *args, **kwds):
    try:
      sig_consumer = request.REQUEST["oauth_consumer_key"]
      sig_method = request.REQUEST["oauth_signature_method"]
      if sig_method == "RSA-SHA1":
        sig_keyname = request.REQUEST["xoauth_signature_publickey"]
      else:
        sig_keyname = ""
    except KeyError:
      logging.exception("Invalid signature!")
      return HttpResponseForbidden("This request was not signed correctly.")
            
    # Special case for ning, since each of their networks has a unique consumer
    # TODO: this needs to be more efficient
    sig_consumer = re.sub("[^.]+\.ning\.com", "ning.com", sig_consumer)
    
    # Special case for Netlog, which sends the country code for each key
    sig_keyname = re.sub("http://[a-z]{2}.api.netlog.com/opensocial/public.cer",
                         "http://api.netlog.com/opensocial/public.cer",
                         sig_keyname)
  
    key = models.ddSigningKey.get(consumer=sig_consumer, method=sig_method, 
        name=sig_keyname)
  
    if key is None:
      return HttpResponseForbidden("No key was found for this request.")
      
    valid = False
    if sig_method == "RSA-SHA1":
      valid = _validate_rsa_sha1_sig(request, key)
    elif sig_method == "HMAC-SHA1":
      valid = _validate_hmac_sha1_sig(request, key)
      
    if not valid:
      logging.warning("Invalid signature!")
      return HttpResponseForbidden("The request was invalid.")

    return func(request, *args, **kwds)
  return validate_wrapper

def viewer_required(func):
  """
  Decorator that adds the current viewer record to the request.  If the viewer
  ID is supplied but there is no record for the viewer, one will be created.
  """
  def viewer_wrapper(request, *args, **kwds):
    try:
      viewer_id = request.REQUEST["opensocial_viewer_id"]
      consumer_key = request.REQUEST["oauth_consumer_key"]
    except KeyError:
      return HttpResponseBadRequest("The request was missing parameters.")
        
    request.viewer = models.ddOpenSocialAccount.get_or_insert(
        container=consumer_key, 
        user_id=viewer_id,
        name=request.REQUEST.get("viewer_name", None))
    
    if not request.viewer:
      return HttpResponseServerError("There was a problem getting the viewer.")
    
    return func(request, *args, **kwds)
  return viewer_wrapper
  
def owner_required(func):
  """
  Decorator that adds the current owner record to the request.  If the owner
  ID is supplied but there is no record for the owner, one will be created.
  """
  def owner_wrapper(request, *args, **kwds):
    try:
      owner_id = request.REQUEST["opensocial_owner_id"]
      consumer_key = request.REQUEST["oauth_consumer_key"]
    except KeyError:
      return HttpResponseBadRequest("The request was missing parameters.")

    request.owner = models.ddOpenSocialAccount.get_or_insert(
        container=consumer_key, 
        user_id=owner_id)

    if not request.owner:
      return HttpResponseServerError("There was a problem getting the owner.")

    return func(request, *args, **kwds)
  return owner_wrapper

### Methods ###

def index(request):
  """Handles requests for http://osda.appspot.com/"""
  return respond(request, "index.html", { 
      "message" : "Hello, world.",
      "view" : "home"
  })

def tools(request):
  return respond(request, "tools.html", {
     "view" : "tools",
  })
  
def help(request, help_page=None):
  """Handles requests for http://osda.appspot.com/help/<help_page>"""
  if not help_page:
    help_page = "index"

  filename = os.path.join("help", help_page + ".html")
  fullpath = os.path.join(os.path.dirname(__file__), '..', 'templates', 
                          filename)

  if (os.path.exists(fullpath)):
    return respond(request, fullpath, {
      "view" : "help"
    })
  else:
    return HttpResponseNotFound("That help page was not found")

@validate_signature
def api_can_save(request):
  """
  API method to determine whether the gadget making the call can save code or
  not.  This is contingent on the opensocial_viewer_id being passed in the
  valid signed request.
  """
  viewer_id = request.REQUEST.get("opensocial_viewer_id", None)
  oauth_consumer_key = request.REQUEST.get("oauth_consumer_key", None)
  
  logging.info("[api_can_save] viewer_id = [%s] oauth_consumer_key=[%s]", 
      viewer_id, oauth_consumer_key)

  if viewer_id is None or viewer_id == "":
    return respond_json(request,
        status="failure", 
        message="Cannot save a sample.")
  else:
    return respond_json(request,
        status="success",
        message="Can save.")
        
@validate_signature
@owner_required
def api_list_owner_samples(request):
  """
  Lists samples for the current request owner.
  """
  samples = request.owner.samples
  payload = []
  for sample in samples:
    payload.append(sample.metadata)  

  if len(payload) > 0:
    return respond_json(request, status="success", payload=payload)
  else:
    return respond_json(request, status="success", payload=payload,
        message="This user has no saved samples.")
    
@validate_signature
@owner_required
def api_list_owner_xmlspecs(request):
  """
  Lists xmlspecs for the current request owner.
  """
  specs = request.owner.xmlspecs
  payload = []
  for spec in specs:
    payload.append(spec.metadata)  

  if len(payload) > 0:
    return respond_json(request, status="success", payload=payload)
  else:
    return respond_json(request, status="success", payload=payload,
        message="This user has no saved xml specs.")
  
@post_required
@validate_signature
@viewer_required
def api_save_sample(request):
  """
  Saves a sample for the current viewer.
  """
  try:
    sample_name = request.REQUEST["dafos_sample_name"].encode('utf-8')
    sample_text = request.REQUEST["dafos_sample_text"].encode('utf-8')
  except KeyError:
    return respond_json(request, status="failure", 
        message="The sample name was not supplied or the sample was empty.")
    
  logging.debug("[api_save_sample] sample_name = [%s], viewer_id = [%s]", 
      sample_name, request.viewer.opensocial_id)
  
  #Create or save the sample
  sample = models.ddJavaScriptCodeSample.get_or_insert(request.viewer.developer, 
      sample_name)
      
  if not sample:
    return respond_json(request, status="failure", 
        message="There was a problem saving the sample.")
  
  sample.text = db.Text(sample_text, "utf-8");
  db.put(sample)

  return respond_json(request, status="success", message="Saved the sample.",
      payload=sample.data)
  
@post_required
@validate_signature
@viewer_required
def api_save_xmlspec(request):
  """
  Saves an xml spec for the current viewer.
  """
  try:
    spec_name = request.REQUEST["dafos_xmlspec_name"].encode('utf-8')
    spec_text = request.REQUEST["dafos_xmlspec_text"].encode('utf-8')
  except KeyError:
    return respond_json(request, status="failure", 
        message="The xml spec name was not supplied or the spec was empty.")
    
  logging.debug("[api_save_xmlspec] spec_name = [%s], viewer_id = [%s]", 
      spec_name, request.viewer.opensocial_id)
  
  #Create or save the spec
  spec = models.ddXmlSpec.get_or_insert(request.viewer.developer, 
      spec_name)
      
  if not spec:
    return respond_json(request, status="failure", 
        message="There was a problem saving the spec.")
  
  spec.text = db.Text(spec_text, "utf-8");
  db.put(spec)

  return respond_json(request, status="success", message="Saved the XML spec.",
      payload=spec.data)
  
@post_required
@validate_signature
@viewer_required
def api_delete_record(request, record_id):
  """
  Deletes the given record
  """  
  try:
    record = db.get(record_id)
  except db.BadKeyError:
    return respond_json(request, status="failure", 
        message="The record was not found")
  
  if record.developer.key() != request.viewer.developer.key():
    return respond_json(request, status="failure", 
        message="The record was not found or you were not the owner.")
        
  message = (u"Deleted record %s." % record.name).encode("utf-8")
  db.delete(record)
  return respond_json(request, status="success", message=message)
  
@post_required
@validate_signature
@viewer_required
def api_clear_cache(request):
  """
  Clears the cache for the supplied url
  """
  try:
    url = request.REQUEST["dafos_clear_cache_url"]
  except KeyError:
    return respond_json(request, status="failure", 
        message="This request was malformed.")

  # Currently only supports orkut's clear cache API
  # TODO: Offer this functionality on more containers
  if not "orkut.com" == request.REQUEST["oauth_consumer_key"]:
    return respond_json(request, status="failure", 
        message="Only orkut is currently supported.")
  
  flushed_url = models.ddFlushedUrl.gql("WHERE url = :1", url).get()
  if flushed_url is not None:
    updated_delta = datetime.datetime.now() - flushed_url.updated
    max_time_delta = datetime.timedelta(minutes=10)

    if (updated_delta < max_time_delta):
      return respond_json(request, status="failure", 
          message="Please wait 10 minutes before clearing this URL again.")
  else:
    flushed_url = models.ddFlushedUrl(url=url)
    db.put(flushed_url)
  
  fetch_url = "http://www.gmodules.com/gadgets/removefromcache?url=%s" % url
  response = urlfetch.fetch(fetch_url, method=urlfetch.GET)
  
  if response.status_code == 200:
    return respond_json(request, status="success", message=response.content)
  else:
    return respond_json(request, status="failure", message=response.content)    

def api_get_record(request, record_id):
  """
  Returns the given record as JSON
  """  
  try:
    record = db.get(record_id)
    return respond_json(request, status="success", payload=record.data)
  except db.BadKeyError:
    return respond_json(request, status="failure", 
        message="There was no record with that key.")

def api_list_samples(request):
  """
  Returns a list of the global code samples
  """  
  samples = models.ddJavaScriptCodeSample.gql(
      "WHERE is_global = True ORDER BY updated DESC").fetch(100)
  
  if len(samples) > 0:
    payload = []
    for sample in samples:
      payload.append(sample.data)  
    return respond_json(request, status="success", payload=payload)
  else:
    return respond_json(request, status="success", 
        message="There are no global samples.", payload=[])
  
def maintenance(request):
  """
  Displays a message indicating that the site is down for maintenance.
  """
  return respond(request, "maintenance.html")
  
def maintenance_json(request):
  """
  Displays a JSON error status indicating that the site is down for maintenance
  """
  return respond_json(request, status="failure", 
      message="Sorry, OSDA is currently down for maintenance.",
      payload=[])
      
@admin_required
def admin(request):
  """
  Handles requests for http://osda.appspot.com/admin/

  GET shows a blank form, POST processes it.
  """
  if request.method != "POST":
    form = AdminKeysForm()
    return respond(request, "admin.html", { "form" : form })

  form = AdminKeysForm(request.POST)
  
  if form.is_valid() and form.cleaned_data["ok"] == True:
    keys = []
    # Combine the public and private keys lists
    keylist = []
    keylist.extend(settings.PRIVATE_KEYS)
    keylist.extend(settings.PUBLIC_KEYS)
    for key in keylist: 
      models.ddSigningKey.set(consumer=key[0], method=key[1], name=key[2], 
          value=key[3])
      keys.append(key[0])
    return respond(request, "admin.html", { 
      "form": form,
      "keys": keys,
    })
  else:
    return respond(request, "admin.html", { "form": form })
 
@admin_required
@code_required
def update_code(request):
  """
  Handles POSTS for http://osda.appspot.com/code/sample/<code_id>
  """
  try:
    sample_text = request.REQUEST["sample_text"].encode('utf-8')
    description = request.REQUEST["sample_description"].encode('utf-8')
  except KeyError:
    return HttpResponseBadRequest("The request was missing parameters.")
      
  is_global = request.REQUEST.get("sample_is_global", "off") == "on"
  
  try:
    request.code_sample.text = db.Text(sample_text, "utf-8")
    request.code_sample.is_global= is_global
    request.code_sample.description = db.Text(description, "utf-8")
    db.put(request.code_sample)
    #TODO: Maybe include a querystring message to display
    return HttpResponseRedirect(request.build_absolute_uri())      
  except db.BadKeyError:
    return HttpResponseServerError("There was a problem updating this sample")

@code_required
def show_code(request):
  """
  Handles requests for http://osda.appspot.com/code/sample/<code_id>
  """
  if request.method == 'POST':
    return update_code(request)
    
  return respond(request, "code.html", request.code_sample.data)
    
@post_required
def clipboard(request):
  """
  Displays a Flash movie that copies the posted data to the user's clipboard.
  """
  try:
    clipboard_text = request.REQUEST["clipboard_data"].encode('utf-8')
  except KeyError:
    return HttpResponseBadRequest("The request was missing parameters.")
  
  return respond(request, "clipboard.html", { "data" : clipboard_text })

### Helpers ###

def respond(request, template, params=None):
  """
  Helper to render a response, passing standard stuff to the response.

  Args:
    request: The request object.
    template: The template name.
    params: A dict giving the template parameters; modified in-place.

  Returns:
    Whatever render_to_response(template, params) returns.

  Raises:
    Whatever render_to_response(template, params) raises.
  """
  if params is None:
    params = {}
  params['request'] = request
  params['user'] = request.user
  params['base_url'] = "http://" + request.get_host()
  params['is_admin'] = request.user_is_admin
  params['is_dev'] = IS_DEV
  full_path = request.get_full_path().encode('utf-8')
  
  if 'view' not in params:
    params['view'] = "home"
  
  if request.user is None:
    params['sign_in'] = users.create_login_url(full_path)
  else:
    params['sign_out'] = users.create_logout_url(full_path)

  try:
    return render_to_response(template, params)
  except DeadlineExceededError:
    logging.exception('DeadlineExceededError')
    return HttpResponse('DeadlineExceededError')
  except MemoryError:
    logging.exception('MemoryError')
    return HttpResponse('MemoryError')
  except AssertionError:
    logging.exception('AssertionError')
    return HttpResponse('AssertionError')

def respond_json(request, status="success", message="", payload=None):
  """
  Responds as a JSON string, wrapped in OSDA JSON api metadata.
  """
  json = { "status" : status, "message" : message }
  if payload != None:
    json["payload"] = payload

  return HttpResponse(simplejson.dumps(json), mimetype="text/plain")

def _get_oauth_base_string(request):
  """
  Builds the signature base string (as defined by OAuth) for this request.
  """
  params = {}
  for key, value in request.REQUEST.items():
    params[key] = value.encode('utf-8', 'ignore')

  # Rebuild the message hash locally
  oauth_request = oauth.OAuthRequest(
      http_method=request.method, 
      http_url=request.build_absolute_uri(), 
      parameters=params)
      
  base_str = '&'.join((
      oauth.escape(oauth_request.get_normalized_http_method()),
      oauth.escape(oauth_request.get_normalized_http_url()),
      oauth.escape(oauth_request.get_normalized_parameters()),))
      
  return base_str
  
def _validate_rsa_sha1_sig(request, key):
  """
  Attempts to validate the given request using OAuth with RSA-SHA1 and the
  supplied key.
  
  Args:
    key: the SigningKey for this request
    
  Returns:
    True if the signature is valid, false otherwise
  """
  exponent = 65537
  public_key_str = key.value 
  public_key_long = long(public_key_str, 16)
  public_key = RSA.construct((public_key_long, exponent))
  message = _get_oauth_base_string(request)
  local_hash = hashlib.sha1(message).digest()

  # Apply the public key to the signature from the remote host
  sig = base64.decodestring(urllib.unquote(request.REQUEST["oauth_signature"]))
  try:
    remote_hash = public_key.encrypt(sig, '')[0][-20:]
  except:
    return False

  # Verify that the locally-built value matches the value from the remote server.
  if local_hash == remote_hash:
    return True
  else:
    logging.warning("Validating RSA failed!")
    logging.info("local hash: [%s]",
        base64.encodestring(local_hash).encode("ascii", "ignore"))
    logging.info("remote hash: [%s]",
        base64.encodestring(remote_hash).encode("ascii", "ignore"))
    logging.info("signature base: [%s]", message)
    return False
  
def _validate_hmac_sha1_sig(request, key):
  """
  Attempts to validate the given request using OAuth with HMAC-SHA1 and the
  supplied key.
  
  Args:
    key: the SigningKey for this request
    
  Returns:
    True if the signature is valid, false otherwise
  """
  hmackey = '%s&' % oauth.escape(key.value)
  message = _get_oauth_base_string(request)
  local_hash = hmac.new(hmackey, message, hashlib.sha1).digest()
  
  sig = base64.decodestring(urllib.unquote(request.REQUEST["oauth_signature"]))
  
  if local_hash == sig:
    return True
  else:
    logging.warning("Validating HMAC failed!  [%s][%s]", local_hash, sig)
    return False